<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">

    <title>3D Physics Gravity Balls — Alien.js</title>

    <link rel="preconnect" href="https://fonts.gstatic.com">
    <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Roboto+Mono">
    <link rel="stylesheet" href="../assets/css/style.css">

    <script type="module">
        import { AdditiveBlending, BloomCompositeMaterial, BlurMaterial, Color, ColorManagement, CopyMaterial, DepthMaterial, DirectionalLight, DisplayOptions, DrawBuffers, DynamicDrawUsage, EnvironmentTextureLoader, EventEmitter, GLSL3, Group, HemisphereLight, IcosahedronGeometry, ImageBitmapLoaderThread, InstancedMesh, LinearSRGBColorSpace, LuminosityMaterial, MathUtils, Matrix4, Mesh, MeshBasicMaterial, MeshMatcapMaterial, MeshStandardMaterial, MotionBlurCompositeMaterial, NoBlending, NormalMaterial, OimoPhysicsBuffer, OimoPhysicsController, OrthographicCamera, Panel, PanelItem, PerspectiveCamera, PlaneGeometry, PointLight, RawShaderMaterial, RepeatWrapping, Scene, ShaderChunk, TextureLoader, Thread, UI, UnrealBloomBlurMaterial, Vector2, Vector3, VolumetricLightLensflareMaterial, WebGLRenderTarget, WebGLRenderer, getFullscreenTriangle, getKeyByValue, getViewSize, headsTails, ticker } from '../../build/alien.three.js';

        const isDebug = /[?&]debug/.test(location.search);

        const count = parseInt(location.search.slice(1), 10) || 100;

        const layers = {
            default: 0,
            buffers: 1,
            occlusion: 2
        };

        const colors = {
            lightColor: 0xf44336
        };

        // Based on https://threejs.org/examples/#webgl_multiple_rendertargets by takahirox
        // Based on https://oframe.github.io/ogl/examples/?src=mrt.html by gordonnl
        // Based on https://github.com/gkjohnson/threejs-sandbox/tree/master/motionBlurPass
        // Based on https://github.com/gkjohnson/threejs-sandbox/tree/master/shader-replacement

        import dither from '../../src/shaders/modules/dither/dither.glsl.js';

        class CompositeMaterial extends RawShaderMaterial {
            constructor() {
                super({
                    glslVersion: GLSL3,
                    uniforms: {
                        tColor: { value: null },
                        tNormal: { value: null },
                        tPosition: { value: null },
                        tVelocity: { value: null },
                        tScene: { value: null },
                        tBloom: { value: null },
                        tAdd: { value: null },
                        uDebug: { value: isDebug },
                        uAspect: { value: 1 }
                    },
                    vertexShader: /* glsl */ `
                        in vec3 position;
                        in vec2 uv;

                        out vec2 vUv;

                        void main() {
                            vUv = uv;

                            gl_Position = vec4(position, 1.0);
                        }
                    `,
                    fragmentShader: /* glsl */ `
                        precision highp float;

                        uniform sampler2D tColor;
                        uniform sampler2D tNormal;
                        uniform sampler2D tPosition;
                        uniform sampler2D tVelocity;
                        uniform sampler2D tScene;
                        uniform sampler2D tBloom;
                        uniform sampler2D tAdd;
                        uniform bool uDebug;
                        uniform float uAspect;

                        in vec2 vUv;

                        out vec4 FragColor;

                        ${dither}

                        void main() {
                            // Extract data from render targets
                            vec3 color = texture(tColor, vUv).rgb;
                            vec3 normal = texture(tNormal, vUv).rgb;
                            vec4 pos = texture(tPosition, vUv);
                            vec2 vel = texture(tVelocity, vUv).xy;
                            float depth = pos.a;

                            FragColor = texture(tScene, vUv);

                            FragColor.rgb += texture(tBloom, vUv).rgb;

                            FragColor.rgb += texture(tAdd, vUv).rgb;

                            FragColor.rgb = dither(FragColor.rgb);
                            FragColor.a = 1.0;

                            if (uDebug) {
                                // Render raw render targets in corner
                                vec2 uv = gl_FragCoord.xy / vec2(250.0 * uAspect, 250.0);
                                if (uv.y < 1.0 && uv.x < 1.0) {
                                    FragColor.rgb = vec3(texture(tPosition, mod(uv, 1.0)).a);
                                } else if (uv.y < 2.0 && uv.x < 1.0) {
                                    FragColor.rgb = texture(tVelocity, mod(uv, 1.0)).rgb;
                                } else if (uv.y < 3.0 && uv.x < 1.0) {
                                    FragColor.rgb = texture(tPosition, mod(uv, 1.0)).rgb;
                                } else if (uv.y < 4.0 && uv.x < 1.0) {
                                    FragColor.rgb = texture(tScene, mod(uv, 1.0)).rgb;
                                } else if (uv.y < 5.0 && uv.x < 1.0) {
                                    FragColor.rgb = texture(tBloom, mod(uv, 1.0)).rgb;
                                } else if (uv.y < 6.0 && uv.x < 1.0) {
                                    FragColor.rgb = texture(tAdd, mod(uv, 1.0)).rgb;
                                }
                            }
                        }
                    `,
                    blending: NoBlending,
                    depthTest: false,
                    depthWrite: false
                });
            }
        }

        class ScenePhysics extends EventEmitter {
            constructor(shapes) {
                super();

                this.shapes = shapes;

                this.initPhysics();
            }

            initPhysics() {
                this.physics = new OimoPhysicsBuffer({
                    gravity: new Vector3(0, 0, 0)
                });

                this.shapes.forEach(shape => this.physics.add(shape));

                this.physics.add({
                    name: 'mouse',
                    kinematic: true
                });

                this.physics.add({
                    name: 'mouseJoint',
                    type: 'joint',
                    mode: 'universal',
                    body1: 'sphere',
                    body2: 'mouse'
                });
            }

            // Public methods

            step = () => {
                this.physics.step();

                this.emit('buffer', { array: this.physics.array, buffer: [this.physics.array.buffer] });
            };

            update = array => {
                this.physics.array = array;

                this.step();
            };

            add = object => {
                this.physics.add(object);
            };

            remove = name => {
                this.physics.remove(name);
            };

            setGravity = gravity => {
                this.physics.setGravity(gravity);
            };

            setPosition = (name, position) => {
                this.physics.setPosition(name, position);
            };

            setOrientation = (name, orientation) => {
                this.physics.setOrientation(name, orientation);
            };

            setLinearVelocity = (name, linearVelocity) => {
                this.physics.setLinearVelocity(name, linearVelocity);
            };

            setAngularVelocity = (name, angularVelocity) => {
                this.physics.setAngularVelocity(name, angularVelocity);
            };

            applyImpulse = (name, impulse, positionInWorld) => {
                this.physics.applyImpulse(name, impulse, positionInWorld);
            };
        }

        class ScenePhysicsThread {
            constructor() {
                this.startTime = performance.now();

                this.addListeners();
            }

            addListeners() {
                addEventListener('message', this.onMessage);
            }

            // Event handlers

            onMessage = ({ data }) => {
                this[data.message.fn].call(this, data.message);
            };

            onBuffer = ({ array, buffer }) => {
                postMessage({ event: 'buffer', message: { array } }, buffer);
            };

            // Public methods

            init = ({ shapes }) => {
                this.physics = new ScenePhysics(shapes);
                this.physics.on('buffer', this.onBuffer);

                this.physics.step();

                postMessage({ event: 'ready' });
            };

            update = ({ array }) => {
                this.physics.update(array);

                ticker.onTick(performance.now() - this.startTime);
            };

            add = ({ object }) => {
                this.physics.add(object);
            };

            remove = ({ name }) => {
                this.physics.remove(name);
            };

            setGravity = ({ gravity }) => {
                this.physics.setGravity(gravity);
            };

            setPosition = ({ name, position }) => {
                this.physics.setPosition(name, position);
            };

            setOrientation = ({ name, orientation }) => {
                this.physics.setOrientation(name, orientation);
            };

            setLinearVelocity = ({ name, linearVelocity }) => {
                this.physics.setLinearVelocity(name, linearVelocity);
            };

            setAngularVelocity = ({ name, angularVelocity }) => {
                this.physics.setAngularVelocity(name, angularVelocity);
            };

            applyImpulse = ({ name, impulse, positionInWorld }) => {
                this.physics.applyImpulse(name, impulse, positionInWorld);
            };
        }

        class ScenePhysicsController extends OimoPhysicsController {
            constructor() {
                super();

                this.buffer = [];
            }

            init() {
                this.initEngine();

                this.addListeners();
            }

            initEngine() {
                this.thread = new Thread({
                    imports: [
                        ['../../build/alien.three.js', 'EventEmitter', 'OimoPhysicsBuffer', 'RigidBodyType', 'Vector3', 'delayedCall', 'ticker']
                    ],
                    classes: [ScenePhysics],
                    controller: [ScenePhysicsThread, 'void init', 'void update', 'void add', 'void remove', 'void setGravity', 'void setPosition', 'void setOrientation', 'void setLinearVelocity', 'void setAngularVelocity', 'void applyImpulse']
                });

                this.thread.init({ shapes: this.shapes });
            }

            addListeners() {
                this.thread.on('buffer', this.onBuffer);
            }

            // Event handlers

            onBuffer = ({ array }) => {
                this.buffer.push(array);
            };

            // Public methods

            update = () => {
                const array = this.buffer.shift();

                if (array) {
                    this.step(array);

                    this.thread.update({ array, buffer: [array.buffer] });
                }
            };

            addJoint = object => {
                this.thread.add({ object });
            };

            remove = name => {
                this.thread.remove({ name });
            };

            setGravity = gravity => {
                this.thread.setGravity({ gravity });
            };

            setPosition = (name, position) => {
                this.thread.setPosition({ name, position });
            };

            setOrientation = (name, orientation) => {
                this.thread.setOrientation({ name, orientation });
            };

            setLinearVelocity = (object, linearVelocity, index) => {
                const { name } = this.getObjectBody(object, index);

                this.thread.setLinearVelocity({ name, linearVelocity });
            };

            setAngularVelocity = (object, angularVelocity, index) => {
                const { name } = this.getObjectBody(object, index);

                this.thread.setAngularVelocity({ name, angularVelocity });
            };

            applyImpulse = (object, impulse, positionInWorld, index) => {
                const { name } = this.getObjectBody(object, index);

                this.thread.applyImpulse({ name, impulse, positionInWorld });
            };

            ready = () => this.init();
        }

        // https://adinunz.io/translucentPearls/

        // Based on http://lo-th.github.io/Oimo.js/#planet

        class Balls extends Group {
            constructor() {
                super();

                // Physics
                this.name = 'balls';
                this.autoSleep = false;
            }

            async initMesh() {
                const { physics, anisotropy, loadTexture } = WorldController;

                const geometry = new IcosahedronGeometry(1, 12);

                // Second set of UVs for aoMap and lightMap
                // https://threejs.org/docs/#api/en/materials/MeshStandardMaterial.aoMap
                geometry.attributes.uv1 = geometry.attributes.uv;

                // Textures
                const [map, normalMap, ormMap, thicknessMap] = await Promise.all([
                    // loadTexture('uv.jpg'),
                    loadTexture('pbr/pitted_metal_basecolor.jpg'),
                    loadTexture('pbr/pitted_metal_normal.jpg'),
                    // https://occlusion-roughness-metalness.glitch.me/
                    loadTexture('pbr/pitted_metal_orm.jpg'),
                    loadTexture('pbr/pitted_metal_height.jpg')
                ]);

                map.anisotropy = anisotropy;
                map.wrapS = RepeatWrapping;
                map.wrapT = RepeatWrapping;
                map.repeat.set(2, 1);

                normalMap.anisotropy = anisotropy;
                normalMap.wrapS = RepeatWrapping;
                normalMap.wrapT = RepeatWrapping;
                normalMap.repeat.set(2, 1);

                ormMap.anisotropy = anisotropy;
                ormMap.wrapS = RepeatWrapping;
                ormMap.wrapT = RepeatWrapping;
                ormMap.repeat.set(2, 1);

                thicknessMap.anisotropy = anisotropy;
                thicknessMap.wrapS = RepeatWrapping;
                thicknessMap.wrapT = RepeatWrapping;
                thicknessMap.repeat.set(2, 1);

                const material = new MeshStandardMaterial({
                    color: new Color().offsetHSL(0, 0, -0.65),
                    metalness: 0.7,
                    roughness: 2,
                    map,
                    metalnessMap: ormMap,
                    roughnessMap: ormMap,
                    aoMap: ormMap,
                    aoMapIntensity: 1,
                    normalMap,
                    normalScale: new Vector2(3, 3)
                });

                // Second channel for aoMap and lightMap
                // https://threejs.org/docs/#api/en/materials/MeshStandardMaterial.aoMap
                material.aoMap.channel = 1;

                // Based on https://github.com/mrdoob/three.js/blob/dev/examples/jsm/shaders/SubsurfaceScatteringShader.js by daoshengmu
                // Based on https://gist.github.com/mattdesl/2ee82157a86962347dedb6572142df7c

                const uniforms = {
                    thicknessMap: { value: thicknessMap },
                    thicknessDistortion: { value: 0.05 },
                    thicknessAmbient: { value: 0 },
                    thicknessAttenuation: { value: 0.8 },
                    thicknessPower: { value: 2 },
                    thicknessScale: { value: 10 }
                };

                material.onBeforeCompile = shader => {
                    shader.uniforms = Object.assign(shader.uniforms, uniforms);

                    shader.fragmentShader = shader.fragmentShader.replace(
                        'void main() {',
                        /* glsl */ `
                        uniform sampler2D thicknessMap;
                        uniform float thicknessDistortion;
                        uniform float thicknessAmbient;
                        uniform float thicknessAttenuation;
                        uniform float thicknessPower;
                        uniform float thicknessScale;

                        void RE_Direct_Scattering(IncidentLight directLight, vec2 uv, vec3 geometryPosition, vec3 geometryNormal, vec3 geometryViewDir, vec3 geometryClearcoatNormal, PhysicalMaterial material, inout ReflectedLight reflectedLight) {
                            vec3 thickness = directLight.color * texture(thicknessMap, uv).r;
                            vec3 scatteringHalf = normalize(directLight.direction + (geometryNormal * thicknessDistortion));
                            float scatteringDot = pow(saturate(dot(geometryViewDir, -scatteringHalf)), thicknessPower) * thicknessScale;
                            vec3 scatteringIllu = (scatteringDot + thicknessAmbient) * thickness;
                            reflectedLight.directDiffuse += material.diffuseColor * directLight.color * scatteringIllu * thicknessAttenuation;
                        }

                        void main() {
                        `
                    );

                    shader.fragmentShader = shader.fragmentShader.replace(
                        '#include <lights_fragment_begin>',
                        ShaderChunk.lights_fragment_begin.replaceAll(
                            'RE_Direct( directLight, geometryPosition, geometryNormal, geometryViewDir, geometryClearcoatNormal, material, reflectedLight );',
                            /* glsl */ `
                            RE_Direct( directLight, geometryPosition, geometryNormal, geometryViewDir, geometryClearcoatNormal, material, reflectedLight );
                            RE_Direct_Scattering(directLight, vAoMapUv, geometryPosition, geometryNormal, geometryViewDir, geometryClearcoatNormal, material, reflectedLight);
                            `
                        )
                    );
                };

                const mesh = new InstancedMesh(geometry, material, count);
                mesh.instanceMatrix.setUsage(DynamicDrawUsage); // Will be updated every frame
                mesh.layers.enable(layers.buffers);
                this.add(mesh);

                const object = new Group();

                for (let i = 0; i < mesh.count; i++) {
                    object.position.x = MathUtils.randFloat(10, 100) * (headsTails() ? -1 : 1);
                    object.position.y = MathUtils.randFloat(10, 100) * (headsTails() ? -1 : 1);
                    object.position.z = MathUtils.randFloat(10, 100) * (headsTails() ? -1 : 1);

                    object.rotation.x = MathUtils.degToRad(MathUtils.randInt(0, 360));
                    object.rotation.y = MathUtils.degToRad(MathUtils.randInt(0, 360));
                    object.rotation.z = MathUtils.degToRad(MathUtils.randInt(0, 360));

                    object.updateMatrix();

                    mesh.setMatrixAt(i, object.matrix);
                }

                mesh.computeBoundingSphere();

                // Physics
                physics.add(mesh);

                this.uniforms = uniforms;
                this.mesh = mesh;
            }

            // Public methods

            ready = () => this.initMesh();
        }

        class Sphere extends Group {
            constructor() {
                super();

                // Physics
                this.name = 'sphere';
                this.autoSleep = false;

                this.initLight();
            }

            initLight() {
                const light = new PointLight(colors.lightColor, 1.9, 4.4, 0);
                this.add(light);
            }

            async initMesh() {
                const { physics, anisotropy, loadTexture } = WorldController;

                const geometry = new IcosahedronGeometry(1, 12);

                // Second set of UVs for aoMap and lightMap
                // https://threejs.org/docs/#api/en/materials/MeshStandardMaterial.aoMap
                geometry.attributes.uv1 = geometry.attributes.uv;

                // Textures
                const [map, normalMap, ormMap, thicknessMap] = await Promise.all([
                    // loadTexture('uv.jpg'),
                    loadTexture('pbr/pitted_metal_basecolor.jpg'),
                    loadTexture('pbr/pitted_metal_normal.jpg'),
                    // https://occlusion-roughness-metalness.glitch.me/
                    loadTexture('pbr/pitted_metal_orm.jpg'),
                    loadTexture('pbr/pitted_metal_height.jpg')
                ]);

                map.anisotropy = anisotropy;
                map.wrapS = RepeatWrapping;
                map.wrapT = RepeatWrapping;
                map.repeat.set(2, 1);

                normalMap.anisotropy = anisotropy;
                normalMap.wrapS = RepeatWrapping;
                normalMap.wrapT = RepeatWrapping;
                normalMap.repeat.set(2, 1);

                ormMap.anisotropy = anisotropy;
                ormMap.wrapS = RepeatWrapping;
                ormMap.wrapT = RepeatWrapping;
                ormMap.repeat.set(2, 1);

                thicknessMap.anisotropy = anisotropy;
                thicknessMap.wrapS = RepeatWrapping;
                thicknessMap.wrapT = RepeatWrapping;
                thicknessMap.repeat.set(2, 1);

                const material = new MeshStandardMaterial({
                    color: new Color(colors.lightColor),
                    metalness: 0.7,
                    roughness: 2,
                    map,
                    metalnessMap: ormMap,
                    roughnessMap: ormMap,
                    aoMap: ormMap,
                    aoMapIntensity: 1,
                    normalMap,
                    normalScale: new Vector2(3, 3)
                });

                // Second channel for aoMap and lightMap
                // https://threejs.org/docs/#api/en/materials/MeshStandardMaterial.aoMap
                material.aoMap.channel = 1;

                // Based on https://github.com/mrdoob/three.js/blob/dev/examples/jsm/shaders/SubsurfaceScatteringShader.js by daoshengmu

                material.onBeforeCompile = shader => {
                    shader.uniforms.thicknessMap = { value: thicknessMap };
                    shader.uniforms.thicknessDistortion = { value: 0.1 };
                    shader.uniforms.thicknessAmbient = { value: 0 };
                    shader.uniforms.thicknessAttenuation = { value: 0.2 };
                    shader.uniforms.thicknessPower = { value: 2 };
                    shader.uniforms.thicknessScale = { value: 16 };

                    shader.fragmentShader = shader.fragmentShader.replace(
                        'void main() {',
                        /* glsl */ `
                        uniform sampler2D thicknessMap;
                        uniform float thicknessDistortion;
                        uniform float thicknessAmbient;
                        uniform float thicknessAttenuation;
                        uniform float thicknessPower;
                        uniform float thicknessScale;

                        void RE_Direct_Scattering(IncidentLight directLight, vec2 uv, vec3 geometryPosition, vec3 geometryNormal, vec3 geometryViewDir, vec3 geometryClearcoatNormal, inout ReflectedLight reflectedLight) {
                            vec3 thickness = directLight.color * texture(thicknessMap, uv).g;
                            vec3 scatteringHalf = normalize(directLight.direction + (geometryNormal * thicknessDistortion));
                            float scatteringDot = pow(saturate(dot(geometryViewDir, -scatteringHalf)), thicknessPower) * thicknessScale;
                            vec3 scatteringIllu = (scatteringDot + thicknessAmbient) * thickness;
                            reflectedLight.directDiffuse += scatteringIllu * thicknessAttenuation * directLight.color;
                        }

                        void main() {
                        `
                    );

                    shader.fragmentShader = shader.fragmentShader.replace(
                        '#include <lights_fragment_begin>',
                        ShaderChunk.lights_fragment_begin.replaceAll(
                            'RE_Direct( directLight, geometryPosition, geometryNormal, geometryViewDir, geometryClearcoatNormal, material, reflectedLight );',
                            /* glsl */ `
                            RE_Direct( directLight, geometryPosition, geometryNormal, geometryViewDir, geometryClearcoatNormal, material, reflectedLight );
                            RE_Direct_Scattering(directLight, vAoMapUv, geometryPosition, geometryNormal, geometryViewDir, geometryClearcoatNormal, reflectedLight);
                            `
                        )
                    );
                };

                const mesh = new Mesh(geometry, material);
                mesh.layers.enable(layers.buffers);
                this.add(mesh);

                // Occlusion mesh
                const occMesh = mesh.clone();
                occMesh.material = new MeshBasicMaterial({ color: colors.lightColor });
                occMesh.layers.set(layers.occlusion);
                this.add(occMesh);

                // Physics
                physics.add(mesh);
            }

            // Public methods

            ready = () => this.initMesh();
        }

        class SceneView extends Group {
            constructor() {
                super();

                this.visible = false;

                this.initViews();
            }

            initViews() {
                this.sphere = new Sphere();
                this.add(this.sphere);

                this.balls = new Balls();
                this.add(this.balls);
            }

            // Public methods

            ready = () => Promise.all([
                this.sphere.ready(),
                this.balls.ready()
            ]);
        }

        class SceneController {
            static init(physics, view) {
                this.physics = physics;
                this.view = view;

                // Mouse light
                this.mouse = new Vector2();
                this.lightPosition = new Vector3();

                // Physics
                this.force = new Vector3();
                this.center = new Vector3(0, 0, 0);
                this.object = new Group();
                this.matrix = new Matrix4();

                this.enabled = true;
                this.animatedOneFramePast = false;

                this.addListeners();
            }

            static addListeners() {
                window.addEventListener('pointermove', this.onPointerMove);
            }

            // Event handlers

            static onPointerMove = ({ clientX, clientY }) => {
                if (!this.view.visible) {
                    return;
                }

                this.mouse.x = (clientX / document.documentElement.clientWidth) * 2 - 1;
                this.mouse.y = 1 - (clientY / document.documentElement.clientHeight) * 2;
            };

            // Public methods

            static resize = () => {
                const { getViewSize } = WorldController;

                const { x, y } = getViewSize(this.view.sphere.position.z);

                this.halfWidth = x / 2;
                this.halfHeight = y / 2;
            };

            static update = () => {
                if (!this.view.visible) {
                    return;
                }

                if (this.enabled || !this.animatedOneFramePast) {
                    // Mouse light
                    this.lightPosition.x = this.mouse.x * this.halfWidth;
                    this.lightPosition.y = this.mouse.y * this.halfHeight;
                    this.lightPosition.z = this.view.sphere.position.z;

                    this.physics.setPosition('mouse', this.lightPosition.toArray());

                    // Physics
                    for (let i = 0; i < this.view.balls.mesh.count; i++) {
                        const { position, quaternion, scale } = this.object;

                        this.view.balls.mesh.getMatrixAt(i, this.matrix);
                        this.matrix.decompose(position, quaternion, scale);

                        this.force.copy(position).negate().normalize().multiplyScalar(0.1);
                        this.physics.applyImpulse(this.view.balls.mesh, this.force.toArray(), this.center.toArray(), i);
                    }

                    this.physics.update();

                    this.animatedOneFramePast = !this.enabled;
                }
            };

            static animateIn = () => {
                this.view.visible = true;
            };

            static ready = async () => {
                await this.view.ready();
                await this.physics.ready();
            };
        }

        class PanelController {
            static init(physics, view, ui) {
                this.physics = physics;
                this.view = view;
                this.ui = ui;

                this.initPanel();
            }

            static initPanel() {
                const { drawBuffers, vlMaterial, luminosityMaterial, bloomCompositeMaterial, compositeMaterial } = RenderManager;

                const physics = this.physics;
                const { balls } = this.view;

                const gravity = new Vector3(0, 0, 0);

                const physicsOptions = {
                    Off: false,
                    Physics: true
                };

                const defaultOptions = {
                    Off: false,
                    Targets: true
                };

                const defaultItems = [
                    {
                        type: 'divider'
                    },
                    {
                        type: 'list',
                        list: defaultOptions,
                        value: getKeyByValue(defaultOptions, compositeMaterial.uniforms.uDebug.value),
                        callback: value => {
                            compositeMaterial.uniforms.uDebug.value = defaultOptions[value];
                        }
                    }
                ];

                const items = [
                    {
                        name: 'FPS'
                    },
                    {
                        type: 'divider'
                    },
                    {
                        type: 'list',
                        list: DisplayOptions,
                        value: getKeyByValue(DisplayOptions, RenderManager.display),
                        callback: (value, item) => {
                            if (!item.hasContent()) {
                                const defaultPanel = new Panel();
                                defaultPanel.animateIn(true);

                                defaultItems.forEach(data => {
                                    defaultPanel.add(new PanelItem(data));
                                });

                                item.setContent(defaultPanel);
                            }

                            RenderManager.display = DisplayOptions[value];

                            if (RenderManager.display === DisplayOptions.Default) {
                                item.toggleContent(true);
                            } else {
                                item.toggleContent(false);
                            }
                        }
                    },
                    {
                        type: 'divider'
                    },
                    {
                        type: 'list',
                        list: physicsOptions,
                        value: getKeyByValue(physicsOptions, SceneController.enabled),
                        callback: value => {
                            SceneController.enabled = physicsOptions[value];
                            drawBuffers.saveState = SceneController.enabled;
                        }
                    },
                    {
                        type: 'slider',
                        name: 'Gravity',
                        min: -10,
                        max: 10,
                        step: 0.1,
                        value: -gravity.y,
                        callback: value => {
                            gravity.y = -value;
                            physics.setGravity(gravity.toArray());
                        }
                    },
                    {
                        type: 'divider'
                    },
                    {
                        type: 'slider',
                        name: 'Interp',
                        min: 0,
                        max: 1,
                        step: 0.01,
                        value: drawBuffers.interpolateGeometry,
                        callback: value => {
                            drawBuffers.interpolateGeometry = value;
                        }
                    },
                    {
                        type: 'slider',
                        name: 'Smear',
                        min: 0,
                        max: 4,
                        step: 0.02,
                        value: drawBuffers.smearIntensity,
                        callback: value => {
                            drawBuffers.smearIntensity = value;
                        }
                    },
                    {
                        type: 'divider'
                    },
                    {
                        type: 'slider',
                        name: 'Exp',
                        min: 0,
                        max: 1,
                        step: 0.01,
                        value: vlMaterial.uniforms.uExposure.value,
                        callback: value => {
                            vlMaterial.uniforms.uExposure.value = value;
                        }
                    },
                    {
                        type: 'slider',
                        name: 'Decay',
                        min: 0.6,
                        max: 1,
                        step: 0.01,
                        value: vlMaterial.uniforms.uDecay.value,
                        callback: value => {
                            vlMaterial.uniforms.uDecay.value = value;
                        }
                    },
                    {
                        type: 'slider',
                        name: 'Density',
                        min: 0,
                        max: 1,
                        step: 0.01,
                        value: vlMaterial.uniforms.uDensity.value,
                        callback: value => {
                            vlMaterial.uniforms.uDensity.value = value;
                        }
                    },
                    {
                        type: 'slider',
                        name: 'Weight',
                        min: 0,
                        max: 1,
                        step: 0.01,
                        value: vlMaterial.uniforms.uWeight.value,
                        callback: value => {
                            vlMaterial.uniforms.uWeight.value = value;
                        }
                    },
                    {
                        type: 'slider',
                        name: 'Clamp',
                        min: 0,
                        max: 1,
                        step: 0.01,
                        value: vlMaterial.uniforms.uClamp.value,
                        callback: value => {
                            vlMaterial.uniforms.uClamp.value = value;
                        }
                    },
                    {
                        type: 'divider'
                    },
                    {
                        type: 'slider',
                        name: 'Scale X',
                        min: 0,
                        max: 4,
                        step: 0.02,
                        value: vlMaterial.uniforms.uLensflareScale.value.x,
                        callback: value => {
                            vlMaterial.uniforms.uLensflareScale.value.x = value;
                        }
                    },
                    {
                        type: 'slider',
                        name: 'Scale Y',
                        min: 0,
                        max: 4,
                        step: 0.02,
                        value: vlMaterial.uniforms.uLensflareScale.value.y,
                        callback: value => {
                            vlMaterial.uniforms.uLensflareScale.value.y = value;
                        }
                    },
                    {
                        type: 'slider',
                        name: 'Exp',
                        min: 0,
                        max: 1,
                        step: 0.01,
                        value: vlMaterial.uniforms.uLensflareExposure.value,
                        callback: value => {
                            vlMaterial.uniforms.uLensflareExposure.value = value;
                        }
                    },
                    {
                        type: 'slider',
                        name: 'Clamp',
                        min: 0,
                        max: 1,
                        step: 0.01,
                        value: vlMaterial.uniforms.uLensflareClamp.value,
                        callback: value => {
                            vlMaterial.uniforms.uLensflareClamp.value = value;
                        }
                    },
                    {
                        type: 'divider'
                    },
                    {
                        type: 'slider',
                        name: 'Distort',
                        min: 0,
                        max: 1,
                        step: 0.01,
                        value: balls.uniforms.thicknessDistortion.value,
                        callback: value => {
                            balls.uniforms.thicknessDistortion.value = value;
                        }
                    },
                    {
                        type: 'slider',
                        name: 'Ambient',
                        min: 0,
                        max: 5,
                        step: 0.01,
                        value: balls.uniforms.thicknessAmbient.value,
                        callback: value => {
                            balls.uniforms.thicknessAmbient.value = value;
                        }
                    },
                    {
                        type: 'slider',
                        name: 'Atten',
                        min: 0,
                        max: 5,
                        step: 0.01,
                        value: balls.uniforms.thicknessAttenuation.value,
                        callback: value => {
                            balls.uniforms.thicknessAttenuation.value = value;
                        }
                    },
                    {
                        type: 'slider',
                        name: 'Power',
                        min: 1,
                        max: 32,
                        step: 0.1,
                        value: balls.uniforms.thicknessPower.value,
                        callback: value => {
                            balls.uniforms.thicknessPower.value = value;
                        }
                    },
                    {
                        type: 'slider',
                        name: 'Scale',
                        min: 0,
                        max: 64,
                        step: 0.1,
                        value: balls.uniforms.thicknessScale.value,
                        callback: value => {
                            balls.uniforms.thicknessScale.value = value;
                        }
                    },
                    {
                        type: 'divider'
                    },
                    {
                        type: 'slider',
                        name: 'Thresh',
                        min: 0,
                        max: 1,
                        step: 0.01,
                        value: luminosityMaterial.uniforms.uThreshold.value,
                        callback: value => {
                            luminosityMaterial.uniforms.uThreshold.value = value;
                        }
                    },
                    {
                        type: 'slider',
                        name: 'Smooth',
                        min: 0,
                        max: 1,
                        step: 0.01,
                        value: luminosityMaterial.uniforms.uSmoothing.value,
                        callback: value => {
                            luminosityMaterial.uniforms.uSmoothing.value = value;
                        }
                    },
                    {
                        type: 'slider',
                        name: 'Strength',
                        min: 0,
                        max: 2,
                        step: 0.01,
                        value: RenderManager.bloomStrength,
                        callback: value => {
                            RenderManager.bloomStrength = value;
                            bloomCompositeMaterial.uniforms.uBloomFactors.value = RenderManager.bloomFactors();
                        }
                    },
                    {
                        type: 'slider',
                        name: 'Radius',
                        min: 0,
                        max: 1,
                        step: 0.01,
                        value: RenderManager.bloomRadius,
                        callback: value => {
                            RenderManager.bloomRadius = value;
                            bloomCompositeMaterial.uniforms.uBloomFactors.value = RenderManager.bloomFactors();
                        }
                    }
                ];

                items.forEach(data => {
                    this.ui.addPanel(new PanelItem(data));
                });
            }
        }

        const BlurDirectionX = new Vector2(1, 0);
        const BlurDirectionY = new Vector2(0, 1);

        class RenderManager {
            static init(renderer, scene, camera, view) {
                this.renderer = renderer;
                this.scene = scene;
                this.camera = camera;
                this.view = view;

                // Volumetric light and lens flare
                this.lightPosition = new Vector3();
                this.vlExposure = 0.1;
                this.vlDecay = 1;
                this.vlDensity = 1;
                this.vlWeight = 0.4;
                this.vlClamp = 1;
                this.lensflareScale = new Vector2(2.5, 2.5);
                this.lensflareExposure = 0.02;
                this.lensflareClamp = 1;
                this.blurResolutionScale = 0.25;
                this.blurAmount = 2.5;

                // Unreal bloom
                this.luminosityThreshold = 0.1;
                this.luminositySmoothing = 1;
                this.bloomStrength = 0.3;
                this.bloomRadius = 0.2;

                // Debug
                DisplayOptions.Light = 8;
                this.display = DisplayOptions.Default;

                this.enabled = true;

                this.initRenderer();
            }

            static initRenderer() {
                const { screenTriangle, resolution, aspect, textureLoader, getTexture } = WorldController;

                // Manually clear
                this.renderer.autoClear = false;

                // Clear colors
                this.clearColor = new Color(0, 0, 0);
                this.currentClearColor = new Color();

                // Fullscreen triangle
                this.screenCamera = new OrthographicCamera(-1, 1, 1, -1, 0, 1);
                this.screen = new Mesh(screenTriangle);
                this.screen.frustumCulled = false;

                // Render targets
                this.renderTargetA = new WebGLRenderTarget(1, 1, {
                    depthBuffer: false
                });

                this.renderTargetB = this.renderTargetA.clone();

                this.renderTargetBlurA = this.renderTargetA.clone();
                this.renderTargetBlurB = this.renderTargetA.clone();

                this.renderTargetBright = this.renderTargetA.clone();
                this.renderTargetsHorizontal = [];
                this.renderTargetsVertical = [];
                this.nMips = 5;

                for (let i = 0, l = this.nMips; i < l; i++) {
                    this.renderTargetsHorizontal.push(this.renderTargetA.clone());
                    this.renderTargetsVertical.push(this.renderTargetA.clone());
                }

                this.renderTargetA.depthBuffer = true;
                this.renderTargetBlurA.depthBuffer = true;

                // G-Buffer
                this.drawBuffers = new DrawBuffers(this.renderer, this.scene, this.camera, layers.buffers, {
                    cameraBlur: false
                });

                // Motion blur composite material
                this.motionBlurCompositeMaterial = new MotionBlurCompositeMaterial(textureLoader, {
                    blueNoisePath: 'blue_noise.png'
                });
                this.motionBlurCompositeMaterial.uniforms.tVelocity.value = this.drawBuffers.renderTarget.textures[1];

                // Occlusion material
                this.blackoutMaterial = new MeshBasicMaterial({ color: 0x000000 });

                // Gaussian blur materials
                this.hBlurMaterial = new BlurMaterial(BlurDirectionX);
                this.hBlurMaterial.uniforms.uBlurAmount.value = this.blurAmount;

                this.vBlurMaterial = new BlurMaterial(BlurDirectionY);
                this.vBlurMaterial.uniforms.uBlurAmount.value = this.blurAmount;

                // Volumetric light material
                this.vlMaterial = new VolumetricLightLensflareMaterial();
                this.vlMaterial.uniforms.uExposure.value = this.vlExposure;
                this.vlMaterial.uniforms.uDecay.value = this.vlDecay;
                this.vlMaterial.uniforms.uDensity.value = this.vlDensity;
                this.vlMaterial.uniforms.uWeight.value = this.vlWeight;
                this.vlMaterial.uniforms.uClamp.value = this.vlClamp;
                this.vlMaterial.uniforms.uLensflareScale.value = this.lensflareScale;
                this.vlMaterial.uniforms.uLensflareExposure.value = this.lensflareExposure;
                this.vlMaterial.uniforms.uLensflareClamp.value = this.lensflareClamp;
                this.vlMaterial.uniforms.uResolution = resolution;

                // Luminosity high pass material
                this.luminosityMaterial = new LuminosityMaterial();
                this.luminosityMaterial.uniforms.uThreshold.value = this.luminosityThreshold;
                this.luminosityMaterial.uniforms.uSmoothing.value = this.luminositySmoothing;

                // Separable Gaussian blur materials
                this.blurMaterials = [];

                const kernelSizeArray = [3, 5, 7, 9, 11];

                for (let i = 0, l = this.nMips; i < l; i++) {
                    this.blurMaterials.push(new UnrealBloomBlurMaterial(kernelSizeArray[i]));
                }

                // Unreal bloom composite material
                this.bloomCompositeMaterial = new BloomCompositeMaterial();
                this.bloomCompositeMaterial.uniforms.tBlur1.value = this.renderTargetsVertical[0].texture;
                this.bloomCompositeMaterial.uniforms.tBlur2.value = this.renderTargetsVertical[1].texture;
                this.bloomCompositeMaterial.uniforms.tBlur3.value = this.renderTargetsVertical[2].texture;
                this.bloomCompositeMaterial.uniforms.tBlur4.value = this.renderTargetsVertical[3].texture;
                this.bloomCompositeMaterial.uniforms.tBlur5.value = this.renderTargetsVertical[4].texture;
                this.bloomCompositeMaterial.uniforms.uBloomFactors.value = this.bloomFactors();

                // Composite material
                this.compositeMaterial = new CompositeMaterial();
                this.compositeMaterial.uniforms.uAspect = aspect;

                // Debug materials
                this.blackoutMaterial = new MeshBasicMaterial({ color: 0x000000 });
                this.matcap1Material = new MeshMatcapMaterial({ matcap: getTexture('matcaps/040full.jpg') });
                this.matcap2Material = new MeshMatcapMaterial({ matcap: getTexture('matcaps/defaultwax.jpg') });
                this.normalMaterial = new NormalMaterial();
                this.normalMaterialInstanced = new NormalMaterial({ instancing: true });
                this.depthMaterial = new DepthMaterial();
                this.depthMaterialInstanced = new DepthMaterial({ instancing: true });
                this.copyMaterial = new CopyMaterial();

                // Based on https://github.com/mrdoob/three.js/issues/26732#issuecomment-1741276120 by donmccurdy
                this.originalMaterials = new Map();
            }

            static bloomFactors() {
                const bloomFactors = [1, 0.8, 0.6, 0.4, 0.2];

                for (let i = 0, l = this.nMips; i < l; i++) {
                    const factor = bloomFactors[i];
                    bloomFactors[i] = this.bloomStrength * MathUtils.lerp(factor, 1.2 - factor, this.bloomRadius);
                }

                return bloomFactors;
            }

            static setLightPosition() {
                this.lightPosition.copy(this.view.sphere.position).project(this.camera);

                const x = (this.lightPosition.x + 1) / 2;
                const y = (this.lightPosition.y + 1) / 2;

                this.vlMaterial.uniforms.uLightPosition.value.set(x, y);
            }

            static rendererState() {
                this.currentOverrideMaterial = this.scene.overrideMaterial;
                this.currentBackground = this.scene.background;
                this.renderer.getClearColor(this.currentClearColor);
                this.currentClearAlpha = this.renderer.getClearAlpha();
            }

            static restoreRendererState() {
                this.scene.overrideMaterial = this.currentOverrideMaterial;
                this.scene.background = this.currentBackground;
                this.renderer.setClearColor(this.currentClearColor, this.currentClearAlpha);

                // Restore original materials
                for (const [object, material] of this.originalMaterials.entries()) {
                    object.material = material;
                }
            }

            // Public methods

            static resize = (width, height, dpr) => {
                this.renderer.setPixelRatio(dpr);
                this.renderer.setSize(width, height);

                width = Math.round(width * dpr);
                height = Math.round(height * dpr);

                this.renderTargetA.setSize(width, height);
                this.renderTargetB.setSize(width, height);

                this.drawBuffers.setSize(width, height);

                // Gaussian blur
                const blurWidth = Math.round(width * this.blurResolutionScale);
                const blurHeight = Math.round(height * this.blurResolutionScale);

                this.renderTargetBlurA.setSize(blurWidth, blurHeight);
                this.renderTargetBlurB.setSize(blurWidth, blurHeight);

                this.hBlurMaterial.uniforms.uResolution.value.set(blurWidth, blurHeight);
                this.vBlurMaterial.uniforms.uResolution.value.set(blurWidth, blurHeight);

                // Unreal bloom
                width = Math.round(width / 2);
                height = Math.round(height / 2);

                this.renderTargetBright.setSize(width, height);

                for (let i = 0, l = this.nMips; i < l; i++) {
                    this.renderTargetsHorizontal[i].setSize(width, height);
                    this.renderTargetsVertical[i].setSize(width, height);

                    this.blurMaterials[i].uniforms.uResolution.value.set(width, height);

                    width = Math.round(width / 2);
                    height = Math.round(height / 2);
                }
            };

            static update = () => {
                const renderer = this.renderer;
                const scene = this.scene;
                const camera = this.camera;

                if (!this.enabled) {
                    renderer.setRenderTarget(null);
                    renderer.clear();
                    renderer.render(scene, camera);
                    return;
                }

                const renderTargetA = this.renderTargetA;
                const renderTargetB = this.renderTargetB;
                const renderTargetBlurA = this.renderTargetBlurA;
                const renderTargetBlurB = this.renderTargetBlurB;
                const renderTargetBright = this.renderTargetBright;
                const renderTargetsHorizontal = this.renderTargetsHorizontal;
                const renderTargetsVertical = this.renderTargetsVertical;

                // Renderer state
                this.rendererState();

                // G-Buffer layer
                camera.layers.set(layers.buffers);

                this.drawBuffers.update();

                if (this.display === DisplayOptions.Velocity) {
                    // Debug pass (render to screen)
                    this.copyMaterial.uniforms.tMap.value = this.drawBuffers.renderTarget.textures[1];
                    this.screen.material = this.copyMaterial;
                    renderer.setRenderTarget(null);
                    renderer.clear();
                    renderer.render(this.screen, this.screenCamera);
                    this.restoreRendererState();
                    return;
                }

                // Scene layer
                camera.layers.set(layers.default);

                renderer.setRenderTarget(renderTargetA);
                renderer.clear();
                renderer.render(scene, camera);

                // Occlusion layer
                scene.background = null;

                scene.overrideMaterial = this.blackoutMaterial;
                renderer.setRenderTarget(renderTargetBlurA);
                renderer.clear();
                renderer.render(scene, camera);
                scene.overrideMaterial = this.currentOverrideMaterial;

                camera.layers.set(layers.occlusion);

                renderer.render(scene, camera);

                // Two pass Gaussian blur (horizontal and vertical)
                this.hBlurMaterial.uniforms.tMap.value = renderTargetBlurA.texture;
                this.screen.material = this.hBlurMaterial;
                renderer.setRenderTarget(renderTargetBlurB);
                renderer.clear();
                renderer.render(this.screen, this.screenCamera);

                this.vBlurMaterial.uniforms.tMap.value = renderTargetBlurB.texture;
                this.screen.material = this.vBlurMaterial;
                renderer.setRenderTarget(renderTargetBlurA);
                renderer.clear();
                renderer.render(this.screen, this.screenCamera);

                // Volumetric light pass
                this.vlMaterial.uniforms.tMap.value = renderTargetBlurA.texture;
                this.setLightPosition();
                this.screen.material = this.vlMaterial;

                if (this.display === DisplayOptions.Light) {
                    // Debug pass (render to screen)
                    renderer.setRenderTarget(null);
                    renderer.clear();
                    renderer.render(this.screen, this.screenCamera);
                    this.restoreRendererState();
                    return;
                } else {
                    renderer.setRenderTarget(renderTargetBlurB);
                    renderer.clear();
                    renderer.render(this.screen, this.screenCamera);
                }

                // Post-processing
                camera.layers.set(layers.default);

                renderer.setClearColor(this.clearColor, 1);

                // Debug override material passes (render to screen)
                if (this.display === DisplayOptions.Depth) {
                    scene.traverseVisible(object => {
                        if (object.material) {
                            this.originalMaterials.set(object, object.material);

                            if (object.isInstancedMesh) {
                                object.material = this.depthMaterialInstanced;
                            } else {
                                object.material = this.depthMaterial;
                            }
                        }
                    });

                    renderer.setRenderTarget(null);
                    renderer.clear();
                    renderer.render(scene, camera);

                    this.restoreRendererState();
                    return;
                } else if (this.display === DisplayOptions.Geometry) {
                    scene.traverseVisible(object => {
                        if (object.material) {
                            this.originalMaterials.set(object, object.material);

                            if (object.isInstancedMesh) {
                                object.material = this.normalMaterialInstanced;
                            } else {
                                object.material = this.normalMaterial;
                            }
                        }
                    });

                    renderer.setRenderTarget(null);
                    renderer.clear();
                    renderer.render(scene, camera);

                    this.restoreRendererState();
                    return;
                } else if (this.display === DisplayOptions.Matcap1) {
                    scene.overrideMaterial = this.matcap1Material;
                    renderer.setRenderTarget(null);
                    renderer.clear();
                    renderer.render(scene, camera);
                    this.restoreRendererState();
                    return;
                } else if (this.display === DisplayOptions.Matcap2) {
                    scene.overrideMaterial = this.matcap2Material;
                    renderer.setRenderTarget(null);
                    renderer.clear();
                    renderer.render(scene, camera);
                    this.restoreRendererState();
                    return;
                }

                // Motion blur pass
                this.motionBlurCompositeMaterial.uniforms.tMap.value = renderTargetA.texture;
                this.screen.material = this.motionBlurCompositeMaterial;
                renderer.setRenderTarget(renderTargetB);
                renderer.clear();
                renderer.render(this.screen, this.screenCamera);

                // Extract bright areas
                this.luminosityMaterial.uniforms.tMap.value = renderTargetB.texture;

                if (this.display === DisplayOptions.Luma) {
                    // Debug pass (render to screen)
                    this.screen.material = this.blackoutMaterial;
                    renderer.setRenderTarget(null);
                    renderer.clear();
                    renderer.render(this.screen, this.screenCamera);
                    this.screen.material = this.luminosityMaterial;
                    this.screen.material.blending = AdditiveBlending;
                    renderer.render(this.screen, this.screenCamera);
                    this.screen.material.blending = NoBlending;
                    this.restoreRendererState();
                    return;
                } else {
                    this.screen.material = this.luminosityMaterial;
                    renderer.setRenderTarget(renderTargetBright);
                    renderer.clear();
                    renderer.render(this.screen, this.screenCamera);
                }

                // Blur all the mips progressively
                let inputRenderTarget = renderTargetBright;

                for (let i = 0, l = this.nMips; i < l; i++) {
                    this.screen.material = this.blurMaterials[i];

                    this.blurMaterials[i].uniforms.tMap.value = inputRenderTarget.texture;
                    this.blurMaterials[i].uniforms.uDirection.value = BlurDirectionX;
                    renderer.setRenderTarget(renderTargetsHorizontal[i]);
                    renderer.clear();
                    renderer.render(this.screen, this.screenCamera);

                    this.blurMaterials[i].uniforms.tMap.value = this.renderTargetsHorizontal[i].texture;
                    this.blurMaterials[i].uniforms.uDirection.value = BlurDirectionY;
                    renderer.setRenderTarget(renderTargetsVertical[i]);
                    renderer.clear();
                    renderer.render(this.screen, this.screenCamera);

                    inputRenderTarget = renderTargetsVertical[i];
                }

                // Composite all the mips
                this.screen.material = this.bloomCompositeMaterial;

                if (this.display === DisplayOptions.Bloom) {
                    // Debug pass (render to screen)
                    renderer.setRenderTarget(null);
                    renderer.clear();
                    renderer.render(this.screen, this.screenCamera);
                    this.restoreRendererState();
                    return;
                } else {
                    renderer.setRenderTarget(renderTargetsHorizontal[0]);
                    renderer.clear();
                    renderer.render(this.screen, this.screenCamera);
                }

                // Composite pass (render to screen)
                this.compositeMaterial.uniforms.tPosition.value = this.drawBuffers.renderTarget.textures[0];
                this.compositeMaterial.uniforms.tVelocity.value = this.drawBuffers.renderTarget.textures[1];
                this.compositeMaterial.uniforms.tScene.value = renderTargetB.texture;
                this.compositeMaterial.uniforms.tBloom.value = renderTargetsHorizontal[0].texture;
                this.compositeMaterial.uniforms.tAdd.value = renderTargetBlurB.texture;
                this.screen.material = this.compositeMaterial;
                renderer.setRenderTarget(null);
                renderer.clear();
                renderer.render(this.screen, this.screenCamera);

                // Restore renderer settings
                this.restoreRendererState();
            };
        }

        class CameraController {
            static init(camera) {
                this.camera = camera;

                this.mouse = new Vector2();
                this.lookAt = new Vector3(0, 0, -2);
                this.origin = new Vector3();
                this.target = new Vector3();
                this.targetXY = new Vector2(2, 0.4);
                this.origin.copy(this.camera.position);

                this.lerpSpeed = 0.02;
                this.enabled = false;

                this.addListeners();
            }

            static addListeners() {
                window.addEventListener('pointermove', this.onPointerMove);
            }

            // Event handlers

            static onPointerMove = ({ clientX, clientY }) => {
                if (!this.enabled) {
                    return;
                }

                this.mouse.x = (clientX / document.documentElement.clientWidth) * 2 - 1;
                this.mouse.y = 1 - (clientY / document.documentElement.clientHeight) * 2;
            };

            // Public methods

            static resize = (width, height) => {
                this.camera.aspect = width / height;
                this.camera.updateProjectionMatrix();

                if (width < height) {
                    this.targetXY.x = 1;
                } else {
                    this.targetXY.x = 2;
                }
            };

            static update = () => {
                if (!this.enabled) {
                    return;
                }

                this.target.x = this.origin.x + this.targetXY.x * this.mouse.x;
                this.target.y = this.origin.y + this.targetXY.y * this.mouse.y;
                this.target.z = this.origin.z;

                this.camera.position.lerp(this.target, this.lerpSpeed);
                this.camera.lookAt(this.lookAt);
            };

            static animateIn = () => {
                this.enabled = true;
            };
        }

        class WorldController {
            static init() {
                this.initWorld();
                this.initLights();
                this.initLoaders();
                this.initEnvironment();
                this.initPhysics();

                this.addListeners();
            }

            static initWorld() {
                this.renderer = new WebGLRenderer({
                    powerPreference: 'high-performance',
                    antialias: true
                });

                // Output canvas
                this.element = this.renderer.domElement;

                // Disable color management
                ColorManagement.enabled = false;
                this.renderer.outputColorSpace = LinearSRGBColorSpace;

                // 3D scene
                this.scene = new Scene();
                this.scene.background = new Color(0x060606);
                this.camera = new PerspectiveCamera(35);
                this.camera.near = 1;
                this.camera.far = 3000;
                this.camera.position.z = 40;
                this.camera.lookAt(this.scene.position);

                // Global geometries
                this.quad = new PlaneGeometry(1, 1);
                this.screenTriangle = getFullscreenTriangle();

                // Global uniforms
                this.resolution = { value: new Vector2() };
                this.texelSize = { value: new Vector2() };
                this.aspect = { value: 1 };
                this.time = { value: 0 };
                this.frame = { value: 0 };

                // Global settings
                this.anisotropy = this.renderer.capabilities.getMaxAnisotropy();
            }

            static initLights() {
                this.scene.add(new HemisphereLight(0x606060, 0x404040, 3));

                const light = new DirectionalLight(0xffffff, 2);
                light.position.set(5, 5, 5);
                this.scene.add(light);
            }

            static initLoaders() {
                this.textureLoader = new TextureLoader();
                this.textureLoader.setPath('../assets/textures/');

                this.environmentLoader = new EnvironmentTextureLoader(this.renderer);
                this.environmentLoader.setPath('../assets/textures/env/');
            }

            static async initEnvironment() {
                this.scene.environment = await this.loadEnvironmentTexture('jewelry_black_contrast.jpg');
                this.scene.environmentIntensity = 1.2;
            }

            static initPhysics() {
                this.physics = new ScenePhysicsController();
            }

            static addListeners() {
                this.renderer.domElement.addEventListener('touchstart', this.onTouchStart);
            }

            // Event handlers

            static onTouchStart = e => {
                e.preventDefault();
            };

            // Public methods

            static resize = (width, height, dpr) => {
                width = Math.round(width * dpr);
                height = Math.round(height * dpr);

                this.resolution.value.set(width, height);
                this.texelSize.value.set(1 / width, 1 / height);
                this.aspect.value = width / height;
            };

            static update = (time, delta, frame) => {
                this.time.value = time;
                this.frame.value = frame;
            };

            static ready = () => Promise.all([
                this.textureLoader.ready(),
                this.environmentLoader.ready()
            ]);

            // Global handlers

            static getTexture = (path, callback) => this.textureLoader.load(path, callback);

            static loadTexture = path => this.textureLoader.loadAsync(path);

            static loadEnvironmentTexture = path => this.environmentLoader.loadAsync(path);

            static getViewSize = object => getViewSize(this.camera, object);
        }

        class App {
            static async init() {
                this.initThread();
                this.initWorld();
                this.initViews();
                this.initControllers();

                this.addListeners();
                this.onResize();

                await SceneController.ready();
                await WorldController.ready();

                this.initPanel();

                CameraController.animateIn();
                SceneController.animateIn();
            }

            static initThread() {
                ImageBitmapLoaderThread.init();

                Thread.count--; // Make room for the physics thread
            }

            static initWorld() {
                WorldController.init();
                document.body.appendChild(WorldController.element);
            }

            static initViews() {
                this.view = new SceneView();
                WorldController.scene.add(this.view);

                this.ui = new UI({ fps: true });
                this.ui.animateIn();
                document.body.appendChild(this.ui.element);
            }

            static initControllers() {
                const { renderer, scene, camera, physics } = WorldController;

                CameraController.init(camera);
                SceneController.init(physics, this.view);
                RenderManager.init(renderer, scene, camera, this.view);
            }

            static initPanel() {
                const { physics } = WorldController;

                PanelController.init(physics, this.view, this.ui);
            }

            static addListeners() {
                window.addEventListener('resize', this.onResize);
                ticker.add(this.onUpdate);
                ticker.start();
            }

            // Event handlers

            static onResize = () => {
                const width = document.documentElement.clientWidth;
                const height = document.documentElement.clientHeight;
                const dpr = window.devicePixelRatio;

                WorldController.resize(width, height, dpr);
                CameraController.resize(width, height);
                SceneController.resize();
                RenderManager.resize(width, height, dpr);
            };

            static onUpdate = (time, delta, frame) => {
                WorldController.update(time, delta, frame);
                CameraController.update();
                SceneController.update();
                RenderManager.update(time, delta, frame);
                this.ui.update();
            };
        }

        App.init();
    </script>
</head>
<body>
</body>
</html>
